Nasza aplikacja szybko się rozrasta, ale stosując charakterystyczne dla Reacta podejście komponentowe, nadal łatwo się w niej odnaleźć. Zwróć uwagę, że odkąd poznaliśmy propsy, prawie wszystkie treści wyświetlane na stronie są zdefiniowane w komponencie App. Za chwilę jeszcze bardziej rozwiniemy to podejście – cała zawartość strony będzie zdefiniowana w pliku src/data/dataStore.js, a App będzie te dane przekazywał do właściwych komponentów.
Dzięki temu osiągniemy rozdział pomiędzy treściami (dataStore.js), komponentami (src/components/*/*.js) oraz stylami (src/styles/*.scss oraz src/components/*/*.scss).
Na tym jednak nie koniec – chcemy, aby nasza aplikacja pozwalała na dodawanie kolejnych kolumn do listy, a w kolumnach – dodawanie kart z notatkami (np. rzeczami do zrobienia, książkami do przeczytania, itp.). Aby było to możliwe, nasza aplikacja musi wiedzieć jakie karty aktualnie znajdują się w kolumnach. Nie tylko w momencie uruchomienia aplikacji, kiedy wstawimy karty i kolumny zdefiniowane w dataStore.js, ale również później, po dodaniu kolejnych kolumn czy kart.
W tym przypadku nie wystarczą nam właściwości komponentu, ponieważ z założenia mają one stałą wartość – czyli od momentu stworzenia instancji danego komponentu, props nie powinny się zmieniać.
Tu właśnie pojawia się kolejny fundamentalny aspekt Reacta – state, czyli stan komponentu. To właśnie w nim przechowywane są wartości, które zmieniają się w czasie istnienia danego komponentu. Użyliśmy określenia "w czasie istnienia", ponieważ komponent wcale nie musi istnieć od otwarcia strony do jej zamknięcia. Możesz sobie wyobrazić, że np. nasza aplikacja wyświetlałaby tylko zestawienie tytułów list, a dopiero po kliknięciu byłby dodawany komponent danej listy, ze wszystkimi wykorzystanymi w niej komponentami.
Wykorzystanie danych z dataStore.js
Najpierw jednak chcemy mieć jakąś początkową zawartość strony. Zajmijmy się więc wykorzystaniem danych zawartych w dataStore.js.
Komponent App
Najpierw, w komponencie App importujemy obiekty pageContents i listData:
import {pageContents, listData} from '../../data/dataStore';
Następnie wykorzystamy je w kodzie JSX:
<main className={styles.component}>
<h1 className={styles.title}>{pageContents.title}</h1>
<h2 className={styles.subtitle}>{pageContents.subtitle}</h2>
<List {...listData} />
</main>
Użycie właściwości obiektu pageContents nie powinno już być niespodzianką, ale wyrażenie {...listData} może Cię dziwić. Spotykamy się z nim pierwszy raz, ale szybko się do niego przyzwyczaimy. Jest to spread operator, który pozwala na rozpakowanie obiektu lub tablicy. Oznacza to, że wszystkie właściwości z listData zostaną przypisane do komponentu List, jako jego właściwości.
Bez użycia spread operator musielibyśmy zapisać:
<List
title={listData.title}
description={listData.description}
image={listData.image}
columns={listData.columns}
/>
A gdyby ten obiekt miał więcej właściwości, ten kod byłby o wiele dłuższy! Przy okazji jednak pozwolił nam na pokazanie, w jaki sposób można zapisać element JSX w wielu liniach. Często będzie to nam pozwalało na znacznie większą przejrzystość kodu!
Komponent List
Przechodząc do komponentu List, musimy wprowadzić kilka zmian. Po pierwsze, zaimportujemy ustawienia z dataStore.js:
import {settings} from '../../data/dataStore';
Następnie w deklaracji typów usuniemy children, a dodamy nowe właściwości:
description: PropTypes.node,
columns: PropTypes.array,
Wreszcie, w domyślnych wartościach propsów usuniemy children, a zamiast niego dodamy:
description: settings.defaultListDescription,
Ostatnia zmiana będzie w kodzie JSX – zamiast {this.props.children} użyjemy {this.props.description}. Jak widzisz, większość zmian wprowadzonych w tym komponencie dotyczy zmiany z użycia propsa children na description. Poza tym zmieniliśmy tylko domyślną wartość opisu, na wartość pobraną z ustawień.
Parsowanie kodu HTML
Jeśli teraz spojrzysz na stronę w przeglądarce, zobaczysz, że wyświetlają nam się fragmenty kodu HTML w tytule listy. No tak, to dlatego, że wcześniej zastosowaliśmy tablicę zawierającą tekst oraz obiekt JSX. Teraz, kiedy pobieramy dane z dataStore.js, nie mamy już takiej możliwości.
Teoretycznie moglibyśmy w dataStore.js stworzyć obiekt JSX, ale – jak się na pewno domyślasz – chcemy pisać aplikację tak, by w przyszłości mogła działać w oparciu o komunikację z API. Wtedy na pewno będziemy otrzymywać tylko tekst (w tym kod HTML), więc zastosujemy inne rozwiązanie.
Zainstaluj pakiet react-html-parser za pomocą komendy:
npm install --save react-html-parser
Następnie w Hero.js zaimportuj go:
import ReactHtmlParser from 'react-html-parser';
Wykorzystamy go do sparsowania kodu HTML w treści nagłówka:
<h2 className={styles.title}>{ReactHtmlParser(props.titleText)}</h2>
Teraz wszystko powinno już działać poprawnie! Zastosuj to samo rozwiązanie dla opisu listy (this.props.description) w komponencie List.
Jeśli wszystko poszło dobrze, jedyne treści wyświetlane na stronie, które nie pochodzą z dataStore.js, to tytuły kolumn. Wstawiliśmy je w poprzednim zadaniu, żeby przetestować czy działa nasz nowy komponent Column. Jak już wspomnieliśmy, kolumny będą też początkowo pobierane z dataStore.js, a następnie będą zapamiętywane w stanie komponentu List.
Stan komponentu
W momencie stworzenia komponentu List musimy nadać mu początkowy stan. W naszym przypadku będzie to lista kolumn, które zawierają karty. Nie musimy ich jednak importować z dataStore.js, ponieważ zrobiliśmy to już w App. Tam, za pomocą spread operator, przekazaliśmy do List wszystkie właściwości z listData, włącznie z tablicą columns. Dlatego początkowy stan listy kolumn będzie korzystał z this.props.columns.
Pojawia się jednak pytanie – gdzie właściwie mamy zdefiniować stan komponentu?
Początkowy stan komponentu
Stan komponentu możemy dodać do niego za pomocą właściwości – podobnie jak metody, dodajemy je bezpośrednio w klasie List. Po tej zmianie początek tej klasy będzie wyglądał następująco:
class List extends React.Component {
state = {
columns: this.props.columns || [],
}
Nowością może być dla Ciebie wykorzystanie operatora lub (||). Jest to częsty zabieg, pozwalający na podanie domyślnej wartości w przypadku, gdy żądana właściwość nie istnieje. Innymi słowy, jeśli this.props.columns nie zostało zdefiniowane, czyli komponent nie otrzymał propsa columns, to w this.state.columns znajdzie się pusta tablica [].
Przy okazji warto zaznaczyć, że tylko i wyłącznie przy ustawianiu początkowego stanu można przypisać wartość do this.state za pomocą znaku równości =. Poza tym przypadkiem zawsze będziemy zmieniać stan za pomocą metody this.setState, odziedziczonej z klasy React.Component.
Wynika to z tego, że w momencie zmiany stanu chcemy ponownie wyrenderować elementy tego komponentu – a metoda this.setState zajmuje się m.in. właśnie tym zadaniem. Wykonuje jednak też inne pożyteczne operacje (z których w tej chwili nie korzystamy), więc zawsze będziemy zmieniać stan za jej pomocą.
Wykorzystanie wartości ze stanu
Teraz kiedy w stanie komponentu List znajduje się już tablica kolumn, jesteśmy gotowi na wyrenderowanie tych kolumn. Pojawi się jednak przed nami pewne wyzwanie. Dobrą praktyką, której należy się trzymać, jest nadawanie klucza (key) każdemu elementowi, który jest elementem tablicy, czy innego zbioru.
Świetnym przykładem są właśnie kolumny – będzie ich wiele, ale w kodzie naszej aplikacji tylko raz użyjemy komponentu Column, ponieważ zrobimy to "w pętli" (a konkretniej, w metodzie .map, która zadziała jak pętla).
W tej sytuacji musimy każdej kolumnie nadać klucz key. Co więcej, musimy to zrobić jawnie – tzn. w kodzie JSX musi być wyrażenie key={}, gdzie w nawiasach będzie jakiś kod JS (np. wartość jakiejś stałej).
Zobaczmy, jak będzie wyglądał ten kod, a za chwilę go sobie omówmy. Zamiast trzech komponentów Column użytych w kodzie JSX metody render, użyj następującego kodu:
{this.state.columns.map(({key, ...columnProps}) => (
<Column key={key} {...columnProps} />
))}
Metoda .map, którą tutaj wykorzystujemy, jest dostępna dla każdej tablicy (array). Służy ona do modyfikacji każdego jej elementu – ale zamiast zmieniać tablicę, na której została wykonana, zwraca nową tablicę ze zmienionymi wartościami.
Innymi słowy, jest to szybki i wygodny sposób na stworzenie tablicy, której każdy element jest przekonwertowanym elementem tablicy this.state.columns. Owo przekonwertowanie polega na stworzeniu instancji klasy Column za pomocą kodu JSX, wraz z przypisaniem jej właściwości z danego elementu tablicy wejściowej (this.state.columns).
Argumentem metody .map jest funkcja strzałkowa. Metoda .map będzie tej funkcji przekazywać pojedynczy element z tablicy this.state.column. Może być Ci jednak ciężko zrozumieć działanie tej funkcji strzałkowej.
Nie używaj tej składni
Wcześniej już spotkaliśmy się z tym operatorem – pozwalał on na rozpakowanie wszystkich parametrów przekazywanych komponentowi List w pliku App.js. Wyglądał jednak znacznie prościej niż użyty powyżej.
Zapiszmy wykorzystaną powyżej funkcję strzałkową w sposób niezgodny z dobrymi praktykami, ale za to łatwiejszy do zrozumienia.
function(singleColumn){
const key = singleColumn.key;
const columnProps = {};
for(let propName in singleColumn){
if(propName != 'key'){
columnProps[propName] = singleColumn[propName];
}
}
return <Column key={key} {...columnProps} />
}
Mamy funkcję, która otrzymuje jeden argument. Z niego zostaje zapisana właściwość key w stałej key, a cała reszta właściwości z argumentu zostaje zapisana w obiekcie columnProps. Na końcu zwracamy obiekt JSX, który posiada właściwość key oraz wszystkie pozostałe.
Wyszła jednak z tego całkiem spora funkcja! Tu z pomocą może nam przyjść spread operator, który bardzo szybko wykona operacje wydzielenia właściwości key oraz zapisania całej reszty właściwości w obiekcie columnProps.
function(singleColumn){
{key, ...columnProps} = singleColumn;
return <Column key={key} {...columnProps} />
}
Ten zapis oszczędził nam sporo miejsca! Jednak skoro tylko raz używamy argumentu singleColumn, to możemy w ogóle go nie nazywać, tylko od razu w deklaracji argumentów użyć wyrażenia {key, ...columnProps}.
function({key, ...columnProps}){
return <Column key={key} {...columnProps} />
}
Kiedy zamienimy powyższy kod na funkcję strzałkową, uzyskamy dokładnie taki zapis, jaki wykorzystaliśmy w metodzie .map powyżej.
Możesz zastanowić się, po co w ogóle ta cała operacja – tzn. po co wydzielamy key, kiedy za chwilę trafia on "w to samo miejsce" co reszta właściwości. Wynika to tylko i wyłącznie ze wspomnianej dobrej praktyki przypisywania klucza key w sposób jawny, czyli np. zapis key={key}.
Nie używaj tej składni
Teoretycznie, równie dobrze mógłby zadziałać następujący kod:
(singleColumn) => {
return <Column {...singleColumn} />
}
Problem polega na tym, że developer czytający taki kod nie ma pewności, czy klucz key został w ogóle nadany. Co więcej, to by oznaczało, że w danych źródłowym koniecznie musi być unikalna właściwość key – nie może to być np. id, jak w API naszego poprzedniego projektu.
Dlatego pamiętaj – jeśli w pętlu lub metodzie .map generujemy komponent dla każdego elementu z tablicy, musimy jawnie przypisać klucz tego komponentu.
No, ale dość o kluczach – spójrz teraz na podgląd naszej aplikacji w przeglądarce! Zobaczysz w niej trzy kolumny, o tytułach zdefiniowanych w dataStore.js!
Modyfikacja stanu
Oczywiście, cała idea stanu komponentu polega na tym, że można go zmieniać – co właśnie teraz zaimplementujemy. Potrzebujemy tylko jakiegoś powodu do zmiany stanu – dodawanie nowych kolumn świetnie się do tego sprawdzi!
W tym celu będzie nam potrzebny komponent służący do dodawania nowej kolumny – Creator. Jego konstrukcja jest odrobinę bardziej skomplikowana, dlatego przygotowaliśmy go dla Ciebie – razem z wykorzystywanym przez niego komponentem Button. W tym samym pliku znajdziesz też Icon.js, który wykorzystamy nieco później.
Pobierz pliki komponentów
Umieść te pliki we właściwych katalogach – odpowiednio src/components/Creator oraz src/components/Button. Komponent Creator zaimportujemy w pliku List.js i wykorzystamy, dodając do kodu JSX:
<div className={styles.creator}>
<Creator text={settings.columnCreatorText} action={title => this.addColumn(title)}/>
</div>
Komponent Creator przyjmuje dwie właściwości:
text to treść placeholdera w polu tekstowym, która służy wyjaśnieniu, do czego służy dany komponent,
action zawiera funkcję, która będzie wykonana w momencie kliknięcia guzika "OK" (widocznego po wpisaniu jakiegoś tekstu w pole tekstowe).
Wyjaśnienie wartości przekazanej w action
W tej właściwości chcemy przekazać do komponentu Creator funkcję, która ma zostać wykonana w przypadku kliknięcia guzika "OK". W naszej klasie za chwilę dodamy metodę addColumn, która ma w tej sytuacji być wykonana.
Aby metoda addColumn działała poprawnie, obiekt this, do którego się w niej odwołujemy musi wskazywać na instancję klasy List. Dlatego nie możemy zapisać action={this.addColumn}. Możesz samodzielnie sprawdzić za pomocą console.log czym byłby wtedy obiekt this w metodzie addColumn.
Istnieją dwa popularne rozwiązania tego problemu. Pierwsze z nich zastosowaliśmy powyżej, wykonując funkcję strzałkową. Jak już zapewne wiesz, funkcja ta nie zmienia znaczenia słowa this, a więc nadal wskazuje ono na instancję tej klasy.
Drugim sposobem jest wykorzystanie metody bind dostępnej dla każdej funkcji. Pozwala ona stworzyć funkcję, która będzie powiązana (bound) z kontekstem podanym jako jej argument. W tym podejściu zapisalibyśmy action={this.addColumn.bind(this)}.
Oba rozwiązania są poprawne i z pewnością spotkasz się z każdym z nich. Wybór jednego z nich jest raczej kwestią konwencji – w naszej ocenie pierwsze podejście jest bardziej przejrzyste i z tego względu je stosujemy.
Zobacz na stronie, jak wygląda ten komponent. Pozornie to tylko pole tekstowe, ale po wpisaniu w nim czegokolwiek, pojawiają się guziki "OK" i "Cancel". Po kliknięciu "OK" wykona się funkcja, przekazana komponentowi Creator w propsie action. Aby dokończyć funkcjonalność dodawania kolumn, musimy więc stworzyć metodę addColumn w klasie List, która będzie zmieniała stan za pomocą metody this.setState. Dodaj poniższy kod przed metodą render:
addColumn(title){
this.setState(state => (
{
columns: [
...state.columns,
{
key: state.columns.length ? state.columns[state.columns.length-1].key+1 : 0,
title,
icon: 'list-alt',
cards: []
}
]
}
));
}
Ten zapis może wydawać się bardzo skomplikowany, ale oznacza tyle, co "dodaj do this.state.columns nowy obiekt". Argumentem metody this.setState może być obiekt reprezentujący nowy stan (lub jego fragment – wystarczy podać zmieniane właściwości stanu) albo funkcja. Pierwsze rozwiązanie stosujemy, kiedy stan zmieniamy na wartość niezależną od dotychczasowej wartości stanu. Na przykład, jeśli chcielibyśmy w stanie przechowywać wartość pola tekstowego (co robimy w komponencie Creator, jak przekonasz się z opisu w podsumowaniu modułu), to moglibyśmy przekazywać jej obiekt.
W naszym przypadku jednak zmiana będzie zależała od dotychczasowego stanu, ponieważ chcemy dodać nową kolumnę do już istniejących, a nie wstawić nową zamiast nich.
Nie stosuj tej składni
Dla łatwiejszego zrozumienia powyższego kodu zapisaliśmy go w nieco dłuższy sposób i bez wykorzystania funkcji strzałkowych czy spread operator.
addColumn(title){
this.setState(function(currentState){
let newColumn = {
key: state.columns.length ? state.columns[state.columns.length-1].key+1 : 0,
title,
icon: 'list-alt',
cards: []
};
let newState = Array.from(currentState);
newState.columns.push(newColumn);
return newState;
});
}
Nawet jeśli ta składnia jest dla Ciebie dużo bardziej czytelna, nie stosuj jej w swoim kodzie. Przy developowaniu Reacta standardem jest wcześniej zaprezentowana składnia. Stosuj ją, a szybko zaczniesz ją rozumieć.
Wróć do przeglądarki, w polu tekstowym wpisz nazwę nowej kolumny i kliknij "OK". Oprócz trzech domyślnych kolumn powinna pojawić się dodatkowa kolumna, o tytule takim, jaki został wpisany w pole tekstowe!
Dlaczego tak właściwie to zadziałało? Ponieważ zmiana stanu powoduje ponowne renderowanie komponentu! Właśnie dlatego w metodzie render wykorzystujemy stan aplikacji. Nie musimy martwić się o to, czy i kiedy zmienić jakiś element na stronie. Zajmuje się tym React – my tylko zmieniamy stan komponentu.
Nie musisz się też martwić o to, że takie podejście może obciążać przeglądarkę, w kółko renderując te same elementy na stronie. React jest bardzo sprytny w tym zakresie – zmienia tylko to, co wymaga zmiany. Jeśli użyjemy komponentu Creator do stworzenia nowej kolumny, to React doda tylko nową kolumnę w strukturze DOM – mimo że czytając kod komponentu mogłoby się nam wydawać, że usunie i na nowo doda na stronie cały kod komponentu List.
Właśnie takie rozwiązania sprawiają, że React jest wygodny dla Developera, a jednocześnie pozwala na szybkie i sprawne działanie aplikacji, przy możliwie małym obciążeniu przeglądarki. Ale chyba nie ma się co dziwić – w końcu React został stworzony przez zespół Facebooka do usprawnienia działania ich platformy. ;)